--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit ecbb47042f266cc9761d98cb1be490bf319d6522
Parents : 345a1d1
Author : Ivan <e46112d44649266d71fe2193e00a4710>
Signature : T66BB85Valid, signed by author
Date : 2026-07-18T07:02:10-05:00
feat: fix WebGL visualiser with improved node rendering and new icon handling logic
Changes
4 files changed, 249 insertions(+), 27 deletions(-)
Diff
diff --git a/meshchatx.rsm b/meshchatx.rsm
index d27fa721..0dad8798 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGL.js b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
index dcddf708..7ef0b375 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGL.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGL.js
@@ -14,6 +14,12 @@ const ATLAS_COLS = 16;
const ATLAS_ROWS = 16;
const ATLAS_CAPACITY = ATLAS_COLS * ATLAS_ROWS;
+/** Soft-edge AA width in UV radius units (1.0 = disc edge). Keep tight to avoid fuzzy blobs. */
+export const NODE_EDGE_INNER = 0.96;
+/** Border ring starts inside the disc (untextured and under glyphs). */
+export const NODE_BORDER_INNER = 0.78;
+export const NODE_BORDER_OUTER = 0.96;
+
const NODE_VS = `#version 300 es
layout(location=0) in vec2 a_corner;
layout(location=1) in vec2 a_center;
@@ -54,20 +60,24 @@ out vec4 outColor;
void main() {
float d = length(v_uv);
if (d > 1.0) discard;
- float edge = smoothstep(1.0, 0.82, d);
- float rim = smoothstep(0.92, 1.0, d);
+ // Tight AA so nodes read as crisp discs, not soft fuzzy blobs.
+ float edge = smoothstep(1.0, ${NODE_EDGE_INNER.toFixed(2)}, d);
+ float border = smoothstep(${NODE_BORDER_INNER.toFixed(2)}, ${NODE_BORDER_OUTER.toFixed(2)}, d);
+ vec3 fill = v_color.rgb;
+ vec3 rim = fill * 0.55;
if (v_useTex > 0.5) {
vec2 local = v_uv * 0.5 + 0.5;
vec2 texUV = v_uvOrigin + local * u_cellUv;
vec4 tex = texture(u_atlas, texUV);
- // Opaque RGB icons (and RGB canvases) may report a=0 in some uploads.
- float texA = max(tex.a, 0.001);
- float a = texA * edge * v_color.a;
- if (a < 0.01) discard;
- vec3 rgb = mix(tex.rgb, v_color.rgb, rim * 0.85);
+ float texA = clamp(tex.a, 0.0, 1.0);
+ // Colored disc from node color, optional glyph/logo from atlas on top.
+ vec3 rgb = mix(fill, tex.rgb, texA);
+ rgb = mix(rgb, rim, border * (1.0 - texA));
+ float a = edge * v_color.a;
+ if (a < 0.02) discard;
outColor = vec4(rgb, a);
} else {
- vec3 rgb = mix(v_color.rgb * 0.92, v_color.rgb, 1.0 - rim);
+ vec3 rgb = mix(fill, rim, border);
outColor = vec4(rgb, v_color.a * edge);
}
}
@@ -214,6 +224,64 @@ export function resolveVisualiserAssetUrl(url) {
return trimmed;
}
+/**
+ * True when the asset is a solid-fill network-visualiser badge (colored disc + light glyph).
+ * Those should be converted to white-on-transparent glyphs so the shader can paint node color.
+ * @param {string} url
+ */
+export function isGlyphStyleVisualiserIcon(url) {
+ if (!url || typeof url !== "string") return false;
+ return /\/network-visualiser\//i.test(url);
+}
+
+/**
+ * Prepare atlas RGBA pixels for upload.
+ *
+ * - opaque: force non-empty pixels to a=255 (RGB PNG uploads)
+ * - glyph: keep near-white / bright pixels as white glyphs, clear the solid fill
+ *
+ * @param {Uint8ClampedArray|Uint8Array} data RGBA buffer (mutated)
+ * @param {"opaque"|"glyph"} mode
+ * @returns {{painted:number,glyphPixels:number}}
+ */
+export function prepareVisualiserIconPixels(data, mode = "opaque") {
+ let painted = 0;
+ let glyphPixels = 0;
+ if (!data || data.length < 4) return { painted: 0, glyphPixels: 0 };
+ const glyphMode = mode === "glyph";
+ for (let i = 0; i < data.length; i += 4) {
+ const r = data[i];
+ const g = data[i + 1];
+ const b = data[i + 2];
+ const a = data[i + 3];
+ if (!(r | g | b | a)) continue;
+ painted += 1;
+ if (glyphMode) {
+ // Stock badges: bright glyph on saturated fill. Keep bright pixels.
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
+ const maxc = Math.max(r, g, b);
+ const minc = Math.min(r, g, b);
+ const sat = maxc - minc;
+ const isGlyph = luma >= 185 || (luma >= 150 && sat < 40);
+ if (isGlyph) {
+ data[i] = 255;
+ data[i + 1] = 255;
+ data[i + 2] = 255;
+ data[i + 3] = 255;
+ glyphPixels += 1;
+ } else {
+ data[i] = 0;
+ data[i + 1] = 0;
+ data[i + 2] = 0;
+ data[i + 3] = 0;
+ }
+ } else {
+ data[i + 3] = 255;
+ }
+ }
+ return { painted, glyphPixels };
+}
+
function createIconAtlas(gl) {
const width = ATLAS_COLS * ATLAS_CELL;
const height = ATLAS_ROWS * ATLAS_CELL;
@@ -242,7 +310,7 @@ function createIconAtlas(gl) {
return nextSlot++;
}
- function paintSlot(slot, source) {
+ function paintSlot(slot, source, url) {
if (!scratchCtx || !scratch) return false;
scratchCtx.save();
scratchCtx.setTransform(1, 0, 0, 1, 0, 0);
@@ -259,19 +327,26 @@ function createIconAtlas(gl) {
const dx = Math.floor((ATLAS_CELL - dw) / 2);
const dy = Math.floor((ATLAS_CELL - dh) / 2);
scratchCtx.drawImage(source, dx, dy, dw, dh);
- // Force full opacity for RGB sources that leave alpha at 0 after upload.
const pixels = scratchCtx.getImageData(0, 0, ATLAS_CELL, ATLAS_CELL);
const data = pixels.data;
- let painted = 0;
- for (let i = 0; i < data.length; i += 4) {
- if (data[i] | data[i + 1] | data[i + 2] | data[i + 3]) {
- data[i + 3] = 255;
- painted += 1;
+ const mode = isGlyphStyleVisualiserIcon(url) ? "glyph" : "opaque";
+ if (mode === "glyph") {
+ const backup = new Uint8ClampedArray(data);
+ const { painted, glyphPixels } = prepareVisualiserIconPixels(data, "glyph");
+ if (painted < 8) {
+ scratchCtx.restore();
+ return false;
+ }
+ if (glyphPixels < 8) {
+ data.set(backup);
+ prepareVisualiserIconPixels(data, "opaque");
+ }
+ } else {
+ const { painted } = prepareVisualiserIconPixels(data, "opaque");
+ if (painted < 8) {
+ scratchCtx.restore();
+ return false;
}
- }
- if (painted < 8) {
- scratchCtx.restore();
- return false;
}
scratchCtx.putImageData(pixels, 0, 0);
scratchCtx.restore();
@@ -325,7 +400,7 @@ function createIconAtlas(gl) {
if (slot == null) return null;
const work = loadImageSource(url)
.then((img) => {
- const ok = paintSlot(slot, img);
+ const ok = paintSlot(slot, img, url);
if (typeof img.close === "function") {
try {
img.close();
diff --git a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
index feae91ff..21de699c 100644
--- a/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
+++ b/meshchatx/src/frontend/js/networkVisualiserWebGLEngine.js
@@ -83,8 +83,9 @@ function colorFromNode(node) {
if (rgb) return rgb;
}
if (c && typeof c === "object") {
- const border = c.border || c.background;
- const rgb = hexToRgb01(border);
+ // Border is the vivid badge color; background is a pale tint for vis-network.
+ const fill = c.border || c.background;
+ const rgb = hexToRgb01(fill);
if (rgb) return rgb;
}
return null;
@@ -121,12 +122,18 @@ function kindForNode(node) {
function sizeForNode(node, kind) {
const s = Number(node?.size);
if (Number.isFinite(s) && s > 0) {
- return Math.max(10, Math.min(34, s * 0.55));
+ // Keep WebGL radii close to vis-network so glyphs stay readable.
+ return Math.max(18, Math.min(48, s * 0.9));
}
- if (kind === KIND_ME) return 26;
- if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 18;
- if (kind === KIND_DISCOVERED) return 14;
- return 16;
+ if (kind === KIND_ME) return 32;
+ if (kind === KIND_IFACE_ON || kind === KIND_IFACE_OFF) return 24;
+ if (kind === KIND_DISCOVERED) return 20;
+ return 22;
+}
+
+/** Exported for unit tests. */
+export function webglNodeSizeFor(node, kind) {
+ return sizeForNode(node, kind == null ? kindForNode(node) : kind);
}
function imageForNode(node, kind) {
diff --git a/tests/frontend/networkVisualiserNodeLook.test.js b/tests/frontend/networkVisualiserNodeLook.test.js
new file mode 100644
index 00000000..1cc0278d
--- /dev/null
+++ b/tests/frontend/networkVisualiserNodeLook.test.js
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Regression tests for WebGL visualiser node look:
+ * crisp discs, glyph-on-color badges, readable sizes, no soft fuzzy blobs.
+ */
+
+import { describe, expect, it } from "vitest";
+import {
+ isGlyphStyleVisualiserIcon,
+ NODE_BORDER_INNER,
+ NODE_BORDER_OUTER,
+ NODE_EDGE_INNER,
+ prepareVisualiserIconPixels,
+ resolveVisualiserAssetUrl,
+} from "@/js/networkVisualiserWebGL.js";
+import {
+ graphToSceneRequest,
+ KIND_IFACE_ON,
+ KIND_ME,
+ KIND_PEER,
+ webglNodeSizeFor,
+} from "@/js/networkVisualiserWebGLEngine.js";
+
+describe("visualiser WebGL node look regressions", () => {
+ it("keeps disc soft-edge tight (no fuzzy blob AA)", () => {
+ expect(NODE_EDGE_INNER).toBeGreaterThanOrEqual(0.94);
+ expect(NODE_BORDER_INNER).toBeLessThan(NODE_BORDER_OUTER);
+ expect(NODE_BORDER_OUTER).toBeLessThanOrEqual(NODE_EDGE_INNER + 0.001);
+ });
+
+ it("treats network-visualiser badge PNGs as glyph-style icons", () => {
+ expect(isGlyphStyleVisualiserIcon("/assets/images/network-visualiser/user.png")).toBe(true);
+ expect(isGlyphStyleVisualiserIcon("/assets/images/network-visualiser/interface_connected.png")).toBe(
+ true
+ );
+ expect(isGlyphStyleVisualiserIcon("/assets/images/reticulum_logo_512.png")).toBe(false);
+ expect(isGlyphStyleVisualiserIcon("")).toBe(false);
+ });
+
+ it("extracts white glyph and clears solid fill from badge pixels", () => {
+ // 2x2: blue fill, white glyph, blue fill, empty
+ const data = new Uint8ClampedArray([
+ 59,
+ 130,
+ 246,
+ 255,
+ 255,
+ 255,
+ 255,
+ 255,
+ 59,
+ 130,
+ 246,
+ 255,
+ 0,
+ 0,
+ 0,
+ 0,
+ ]);
+ const { painted, glyphPixels } = prepareVisualiserIconPixels(data, "glyph");
+ expect(painted).toBe(3);
+ expect(glyphPixels).toBe(1);
+ expect(data[0]).toBe(0);
+ expect(data[3]).toBe(0);
+ expect(data[4]).toBe(255);
+ expect(data[7]).toBe(255);
+ expect(data[8]).toBe(0);
+ expect(data[11]).toBe(0);
+ });
+
+ it("opaque mode forces alpha on RGB pixels for logo uploads", () => {
+ const data = new Uint8ClampedArray([10, 20, 30, 0, 0, 0, 0, 0]);
+ const { painted } = prepareVisualiserIconPixels(data, "opaque");
+ expect(painted).toBe(1);
+ expect(data[3]).toBe(255);
+ });
+
+ it("falls back to opaque when glyph extraction would wipe the icon", () => {
+ // All mid-saturation blue: no bright glyph pixels.
+ const data = new Uint8ClampedArray(16);
+ for (let i = 0; i < 16; i += 4) {
+ data[i] = 59;
+ data[i + 1] = 130;
+ data[i + 2] = 246;
+ data[i + 3] = 255;
+ }
+ const backup = new Uint8ClampedArray(data);
+ const first = prepareVisualiserIconPixels(data, "glyph");
+ expect(first.glyphPixels).toBe(0);
+ expect(first.painted).toBe(4);
+ // paintSlot restores the pre-glyph buffer before opaque fallback.
+ data.set(backup);
+ prepareVisualiserIconPixels(data, "opaque");
+ expect(data[3]).toBe(255);
+ expect(data[0]).toBe(59);
+ });
+
+ it("WebGL node sizes stay large enough for glyphs", () => {
+ expect(webglNodeSizeFor({ size: 25 }, KIND_PEER)).toBeGreaterThanOrEqual(18);
+ expect(webglNodeSizeFor({ size: 50 }, KIND_ME)).toBeGreaterThanOrEqual(30);
+ expect(webglNodeSizeFor({ size: 35 }, KIND_IFACE_ON)).toBeGreaterThanOrEqual(24);
+ // Must not shrink to the old fuzzy 0.55 scale (~13px for size 25).
+ expect(webglNodeSizeFor({ size: 25 }, KIND_PEER)).toBeGreaterThan(16);
+ });
+
+ it("graph scene colors use vivid border for disc fill", () => {
+ const req = graphToSceneRequest(
+ [
+ {
+ id: "peer",
+ group: "announce",
+ size: 25,
+ color: { border: "#3b82f6", background: "#eff6ff" },
+ },
+ {
+ id: "direct",
+ group: "announce",
+ size: 25,
+ color: { border: "#10b981", background: "#ecfdf5" },
+ },
+ ],
+ [],
+ { width: 100, height: 100, zoom: 1 }
+ );
+ expect(req.nodes[0].r).toBeCloseTo(0x3b / 255, 2);
+ expect(req.nodes[0].g).toBeCloseTo(0x82 / 255, 2);
+ expect(req.nodes[0].b).toBeCloseTo(0xf6 / 255, 2);
+ expect(req.nodes[1].r).toBeCloseTo(0x10 / 255, 2);
+ expect(req.nodes[1].g).toBeCloseTo(0xb9 / 255, 2);
+ // Pale backgrounds must not become the disc color (that looked washed out).
+ expect(req.nodes[0].r).toBeGreaterThan(0.15);
+ expect(req.nodes[0].size).toBeGreaterThanOrEqual(18);
+ });
+
+ it("resolveVisualiserAssetUrl keeps absolute asset paths resolvable", () => {
+ const url = resolveVisualiserAssetUrl("/assets/images/network-visualiser/user.png");
+ expect(url).toContain("/assets/images/network-visualiser/user.png");
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────